解决几个ViewPager 异常问题 | 深入剖析
本文作者
作者:wurensen
链接:
https://blog.csdn.net/wurensen/article/details/81544776
本文由作者授权发布。
本文所有分析及解决方案都依赖于ViewPager的源码实现,阅读前推荐先阅读:
ViewPager源码分析(发现刷新数据的正确使用姿势)
https://blog.csdn.net/wurensen/article/details/81390641
背景
我们项目常常会遇到首页banner、广告banner的需求,要求一屏能同时看到旁边两页,并且旁边的页面缩小。
类似于下图:
要实现这样的效果很简单,布局中给ViewPager设置合适的paddingLeft、paddingRight,配合clipPadding=false:
<android.support.v4.view.ViewPager
android:id="@+id/vp"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorAccent"
android:clipToPadding="false"
android:paddingLeft="50dp"
android:paddingRight="50dp" />
给ViewPager添加PageTransformer动画实现(padding导致position位置遍历):
@Override
public void transformPage(@NonNull View page, float position) {
if (position >= -1 && position <= 1) {
// [-1,1],中间以及相邻的页面,一般相邻的才会用于计算动画
float scale = SCALE + (1 - SCALE) * (1 - Math.abs(position));
page.setScaleX(scale);
page.setScaleY(scale);
} else {
// [-Infinity,-1)、(1,+Infinity],超出相邻的范围
page.setScaleX(SCALE);
page.setScaleY(SCALE);
}
}
完整代码可查看github上的demo
https://github.com/wurensen/GraceViewPager
问题1:padding导致动画异常
异常现象
先来看看上述代码在滑动页面时会产生什么问题:
可以明显看到,显示的页面并非在中间的时候缩放到最大,而是要往左滑动一点距离才达到最大。
问题分析
直接看transformPage在ViewPager源码中被调用的地方:
protected void onPageScrolled(int position, float offset, int offsetPixels) {
...
if (mPageTransformer != null) {
final int scrollX = getScrollX();
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (lp.isDecor) continue;
final float transformPos = (float) (child.getLeft() - scrollX) / getClientWidth();
mPageTransformer.transformPage(child, transformPos);
}
}
...
}
private int getClientWidth() {
return getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
}
可以看到,transformPos的计算并未减去paddingLeft,这就导致了计算结果偏大。
解决方案
给position重新修正:
private float getPositionConsiderPadding(ViewPager viewPager, View page) {
// padding影响了position,自己生成position
int clientWidth = viewPager.getMeasuredWidth() - viewPager.getPaddingLeft() - viewPager.getPaddingRight();
return (float) (page.getLeft() - viewPager.getScrollX() - viewPager.getPaddingLeft()) / clientWidth;
}
查看运行结果:
问题2:刷新数据动画异常
界面上添加了数据反序、添加数据、删除数据按钮来模拟数据源发生变化的情况。
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.reverse_btn:
Collections.reverse(mData);
mAdapter.notifyDataSetChanged();
break;
case R.id.add_btn:
mData.add(mViewPager.getCurrentItem(), "add item:" + mData.size());
mAdapter.notifyDataSetChanged();
break;
case R.id.delete_btn:
if (mData.size() > 0) {
mData.remove(mViewPager.getCurrentItem());
mAdapter.notifyDataSetChanged();
}
break;
}
}
异常现象
先滑动到item:4,点击数据反序:
问题分析
查看日志:
getItemPosition: oldPos=2,newPos=7
getItemPosition: oldPos=3,newPos=6
getItemPosition: oldPos=4,newPos=5
getItemPosition: oldPos=5,newPos=4
getItemPosition: oldPos=6,newPos=3
transformPage() called with: page =
transformPage() called with: page =
transformPage() called with: page =
transformPage() called with: page =
transformPage() called with: page =
在文章ViewPager源码分析(发现刷新数据的正确使用姿势)已经分析了调用刷新后的流程,可知,在dataSetChanged()中会调用setCurrentItemInternal(),最终会调用到onPageScrolled(),即transformPage()会在刷新过程中被调用。
但是,该回调时刻ViewPager只是确定了各个ItemInfo的属性,包括offset,并未执行onLayout(),所以此时回调的position应该不变才对,为什么和输出的日志不一致?那就往调用方法栈中找,在setCurrentItemInternal()中会调用scrollToItem():
private void scrollToItem(int item, boolean smoothScroll, int velocity,
boolean dispatchSelected) {
final ItemInfo curInfo = infoForPosition(item);
int destX = 0;
if (curInfo != null) {
final int width = getClientWidth();
destX = (int) (width * Math.max(mFirstOffset,
Math.min(curInfo.offset, mLastOffset)));
}
if (smoothScroll) {
smoothScrollTo(destX, 0, velocity);
if (dispatchSelected) {
dispatchOnPageSelected(item);
}
} else {
if (dispatchSelected) {
dispatchOnPageSelected(item);
}
completeScroll(false);
scrollTo(destX, 0);
pageScrolled(destX);
}
}
注意第20行代码调用了scrollTo(destX, 0);,并且destX的值等于目标Page的left。经过上文修正position的计算时,变量viewPager.getScrollX()==destX,这也就解释了为什么日志中postion会依次返回:-4.0,-3.0,-2.0,-1.0,0.0。
显然,在刷新过程中transformPage()返回Page对应的position值,与最终的正确结果相差甚远。
解决方案
那如何能够在数据刷新过程中回调transformPage()时,得到Page对应的position呢?
经过上文问题分析,只要能够知道Page对应在数据中的index,并计算出和目标Page的index间的偏移,该偏移值就是position。
我们知道child的顺序与Page顺序并非一致,并且ViewPager中与ItemInfo相关的方法都不可访问(可反射,但是不推荐,无法兼容后续版本源码改动),所以无法通过ViewPager直接获取对应的数据索引。
但是,开发者在继承PagerAdapter时,返回的视图和数据索引对应关系是由开发者维护的。那我们可以让实现的PagerAdapter提供视图-数据索引的对应关系的接口:
public void notifyDataSetChanged() {
mDataSetChanging = true;
super.notifyDataSetChanged();
mDataSetChanging = false;
}
/**
* 获取页面视图对应的数据索引
*
* @param page 页面视图
* @return 未找到返回-1
*/
public int getPageViewPosition(View page) {
for (ViewItemHolder viewItemHolder : mViewItemHolders) {
if (viewItemHolder.mItemView == page) {
return viewItemHolder.mPosition;
}
}
return -1;
}
/**
* 数据是否正在刷新中,即是否处于{@link #notifyDataSetChanged()}->{@link ViewPager#dataSetChanged()}执行过程
*
* @return 刷新中返回true
*/
public boolean isDataSetChanging() {
return mDataSetChanging;
}
并且在初始化PageTransformer的时候传入该Adapter:
// 拓展的PagerAdapter
private GracePagerAdapter mPagerAdapter;
public GracePageTransformer(@NonNull GracePagerAdapter pagerAdapter) {
mPagerAdapter = pagerAdapter;
}
public void transformPage(@NonNull View page, float position) {
// 数据刷新、填充新page的时候,要判断page真正的位置才能得到正确的position
boolean dataSetChanging = mPagerAdapter.isDataSetChanging();
boolean requirePagePosition = dataSetChanging || viewPager.isLayoutRequested();
if (requirePagePosition) {
int currentItem = viewPager.getCurrentItem();
int pageViewIndex = mPagerAdapter.getPageViewPosition(page);
LogUtil.d("transformPage() requirePagePosition: currentItem = ["
+ currentItem + "], pageViewIndex = [" + pageViewIndex + "]");
if (currentItem == pageViewIndex) {
position = 0;
} else {
position = pageViewIndex - currentItem;
}
} else {
position = getPositionConsiderPadding(viewPager, page);
}
LogUtil.d("transformPage() called with: page = [" + page + "], position = [" + position + "]");
transformPageWithCorrectPosition(page, position);
}
看下运行结果:
可以看到解决代码中还多了viewPager.isLayoutRequested()判断,因为刷新可能包含数据添加,此时添加的View还未进行测量和布局,也会导致动画异常。
进入页面显示item:0,点击添加数据按钮:
日志如下:
instantiateItem() called with: position =
onPageSelected() called with: position =
transformPage() called with: page =
transformPage() called with: page =
transformPage() called with: page =
transformPage() called with: page =
可以发现新添加的Page的动画是错误的,所以该情况下,也需要通过Page去获取对应的索引来计算得到正确的position。
问题3:改变ViewPager的width或paddingLeft、paddingRight导致滚动位置异常
在实际使用场景中,有很多手机是带可动态展示和隐藏的底部操作栏,动态改变布局大小会影响到ViewPager的大小或是Page的大小(比如Page显示的是图片,需要保持比例不变),通过改变padding按钮来动态修改paddingLeft和paddingRight模拟实际场景:
public void onClick(View v) {
switch (v.getId()) {
case R.id.change_padding_btn:
boolean visible = mPlaceholderView.getVisibility() == View.VISIBLE;
mPlaceholderView.setVisibility(visible ? View.GONE : View.VISIBLE);
int padding = visible ? dip2px(50) : dip2px(75);
mViewPager.setPadding(padding, 0, padding, 0);
break;
}
}
异常现象
先滑动到item:1,看下点击改变padding按钮的现象:
可以看到页面明显出现了偏移.
问题分析
调用setPadding()会使得ViewPager重新走测量布局绘制流程。在onMeasure()中会去调用populate(),也会调用到calculatePageOffsets()计算各个ItemInfo的属性,包括offset;在onLayout()中会根据得到的offset和新的childWidth进行child的布局,最后再根据当前的scrollX进行页面绘制。
那为什么会发生偏移呢?
因为getScrollX()的值没有变化。
ViewPager是通过scrollTo()来实现滚动到指定的位置,如果各个child的位置更新了,但是scrollX没有相应的更新,就会出现偏移。
其实ViewPager源码中有考虑到宽度变化后需要重新滚动定位的情况:
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// Make sure scroll position is set correctly.
if (w != oldw) {
recomputeScrollPosition(w, oldw, mPageMargin, mPageMargin);
}
}
private void recomputeScrollPosition(int width, int oldWidth, int margin, int oldMargin) {
if (oldWidth > 0 && !mItems.isEmpty()) {
if (!mScroller.isFinished()) {
mScroller.setFinalX(getCurrentItem() * getClientWidth());
} else {
final int widthWithMargin = width - getPaddingLeft() - getPaddingRight() + margin;
final int oldWidthWithMargin = oldWidth - getPaddingLeft() - getPaddingRight()
+ oldMargin;
final int xpos = getScrollX();
// 该计算方式得到的pageOffset会有误差,xpos越大,误差越大
final float pageOffset = (float) xpos / oldWidthWithMargin;
final int newOffsetPixels = (int) (pageOffset * widthWithMargin);
scrollTo(newOffsetPixels, getScrollY());
}
} else {
final ItemInfo ii = infoForPosition(mCurItem);
final float scrollOffset = ii != null ? Math.min(ii.offset, mLastOffset) : 0;
final int scrollPos =
(int) (scrollOffset * (width - getPaddingLeft() - getPaddingRight()));
if (scrollPos != getScrollX()) {
completeScroll(false);
scrollTo(scrollPos, getScrollY());
}
}
}
注意recomputeScrollPosition()方法中scrollPos的计算方式,会发现宽度的计算都是包含了mPageMargin,但是在计算各个ItemInfo的offset时,已经把mPageMargin计算进去了。也就是说,在onLayout()的时候,各个child布局的时候已经预留了pageMargin的位置,并且child位置取决于offset和childWidth。同时,滚动到具体某一个页面的位置的scrollX也是根据offset*childWidth计算得出。
所以,如果在mPageMargin=0的时候,上述源码不会有问题,但是如果设置了某个值,通过
final float pageOffset = (float) xpos / oldWidthWithMargin;得到的页面偏移就与实际的offset有误差。
解决方案
既然recomputeScrollPosition()有问题,那就自己监听布局变化,当child宽度发生变化后重新滚动修正:
/**
* 布局变化监听
*/
private static final class ViewPagerLayoutChangeListener implements View.OnLayoutChangeListener {
private ViewPager mViewPager;
private int mLastChildWidth;
ViewPagerLayoutChangeListener(ViewPager viewPager) {
mViewPager = viewPager;
}
public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft,
int oldTop, int oldRight, int oldBottom) {
int childWidth = right - left - v.getPaddingLeft() - v.getPaddingRight();
if (childWidth == 0) {
return;
}
if (mLastChildWidth == 0) {
mLastChildWidth = childWidth;
return;
}
if (mLastChildWidth == childWidth) {
return;
}
/*
* 问题:page宽度变化后,layout会正确放置child位置,但是scrollX值仍然是旧值,导致绘制位置偏差;
* 同时,经过数据刷新后scrollX=0不代表定位到第一个页面,取决于最左边child的位置,所以该值有可能是负值;
* 解决方案:根据旧值获取页面偏移,根据页面偏移计算新的scrollX位置
*/
recomputeScrollPosition(mViewPager, mViewPager.getScrollX(), childWidth, mLastChildWidth);
mLastChildWidth = childWidth;
}
/**
* 重新计算滚动位置
*
* @param viewPager ViewPager
* @param scrollX 当前滚动位置
* @param childWidth 新的item宽度
* @param oldChildWidth 旧的item宽度
*/
private static void recomputeScrollPosition(ViewPager viewPager, int scrollX,
int childWidth, int oldChildWidth) {
float pageOffset = (float) scrollX / oldChildWidth;
int newOffsetPixels = (int) (pageOffset * childWidth);
viewPager.scrollTo(newOffsetPixels, viewPager.getScrollY());
}
}
在有无设置pageMargin的情况下都能得到修正:
问题4:setPageMargin()导致滚动位置异常
从上文得知,pageMargin是会影响child的布局以及滚动位置。改变pageMargin按钮来实现pageMargin变化。
public void onClick(View v) {
switch (v.getId()) {
case R.id.change_margin_btn:
int pageMargin = mViewPager.getPageMargin();
if (pageMargin == 0) {
pageMargin = dip2px(10);
} else {
pageMargin = 0;
}
mViewPager.setPageMargin(pageMargin);
break;
}
}
异常现象
先滑动到item:1,看下点击改变pageMargin按钮的现象:
发现位置明显偏移了。
问题分析
直接看setPageMargin()源码:
public void setPageMargin(int marginPixels) {
final int oldMargin = mPageMargin;
mPageMargin = marginPixels;
final int width = getWidth();
recomputeScrollPosition(width, width, marginPixels, oldMargin);
requestLayout();
}
也是调用了recomputeScrollPosition()进行重新滚动定位。上文已经分析了源码该方法有问题,也分析了产生的原因和解决方案。
解决方案
/**
* ViewPager.recomputeScrollPosition()方法源码有Bug,计算的scrollX值有误,导致动态去调用setPageMargin()后,
* 滚动位置有问题。<br/>
* 直接调用该方法替代{@link ViewPager#setPageMargin(int)},可以修正滚动位置错误问题。
*
* @param viewPager ViewPager
* @param pageMargin pageMargin
*/
public static void setPageMargin(@NonNull ViewPager viewPager, int pageMargin) {
int oldPageMargin = viewPager.getPageMargin();
if (pageMargin == oldPageMargin) {
return;
}
int childWidth = viewPager.getMeasuredWidth() - viewPager.getPaddingLeft() - viewPager.getPaddingRight();
if (childWidth == 0) {
viewPager.setPageMargin(pageMargin);
} else {
// setPageMargin()调用后当前item的offset值和childWidth不变,所以直接取出调用前的scrollX值进行定位即可
int oldScrollX = viewPager.getScrollX();
viewPager.setPageMargin(pageMargin);
viewPager.scrollTo(oldScrollX, viewPager.getScrollY());
}
}
为了看到child间的pageMargin,打开开发者模式的显示布局边界,运行结果:
在当前选中为靠后的页面也没有发生偏移。
总结
基于以上结论,为了方便使用,进行了封装,满足以下功能:
支持ViewPager按需添加、删除视图,以及局部刷新;
修复多场景下ViewPager.PageTransformer返回的position错误,让开发者专注于动画实现;
修复ViewPager的width、paddingLeft、paddingRight、pageMargin动态改变导致当前page定位异常的问题;
提供自定义GraceViewPager,可快速实现一屏显示多Page的功能。已开源到github并发布到jcenter,详情:
https://github.com/wurensen/GraceViewPager
推荐阅读:
扫一扫 关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!